前一篇我們透過線段,實作出面來,然而光這樣不夠。畢竟我們在學3D視覺特效,不能只給出了個2D,否則就像當年包龍星他爹給的爛餅一樣。不過沒關係,本篇將實作出3D立體圓餅加以奉還!
對於嘗試開發3D特效來說,圓餅圖的製作可以由數值產生弧度,由弧度產生線段,由線段產生面,再由面產生3D物件。整條流程使得我們接觸每一個層面的製作。對於實戰線段來說再適合不過。不僅如此,其所用到的弧度、三角函數、Extrude等製作方法,都能夠深度的解釋。
不僅如此,在實際應用上,圓餅圖不僅能夠增加高級感、獨特性,其增加的第三個維度「高度」也能呈現另一個維度的資料,使得圓餅圖不再只是呈現比例,還能有其他用途。
本篇,我將示例如何將平面的圓餅圖轉成立體,並加上文字。事實上,除了立體化以外,圓餅圖還能點擊、Hover以及更多的互動。但我這邊就先聚焦在立體化以及文字上。
我們由上一篇開發完的程式碼繼續,這邊附上CodePen可以直接複製程式碼。
https://codepen.io/umas-sunavan/pen/xxjWJbx
我們也可以使用下面的程式碼開始:
import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
const scene = new THREE.Scene();
const windowRatio = window.innerWidth / window.innerHeight
const camera = new THREE.OrthographicCamera(-windowRatio * 10, windowRatio * 10, 10, -10, 0.1,1000)
camera.position.set(0, 3, 15)
// 假設圖表拿到這筆資料
const data = [
{rate: 14.2, name: '動力控制IC'},
{rate: 32.5, name: '電源管理IC'},
{rate: 9.6, name: '智慧型功率IC'},
{rate: 18.7, name: '二極體Diode'},
{rate: 21.6, name: '功率電晶體Power Transistor'},
{rate: 3.4, name: '閘流體Thyristor'},
]
// 我準備了簡單的色票,作為圓餅圖顯示用的顏色
const colorSet = [
0x729ECB,
0xA9ECD5,
0xA881CB,
0xF3A39E,
0xFFD2A1,
0xBBB5AE,
0xE659AB,
0x88D9E2,
0xA77968,
]
const createPie = (startAngle, endAngle, color, depth) => {
const curve = new THREE.EllipseCurve(
0,0,
5,5,
startAngle, endAngle,
false,
0
)
console.log(startAngle, endAngle);
const curvePoints = curve.getPoints(50)
const shape = new THREE.Shape(curvePoints)
shape.lineTo(0,0)
shape.closePath()
const shapeGeometry = new THREE.ShapeGeometry(shape)
const shapeMaterial = new THREE.MeshBasicMaterial({color: color})
const mesh = new THREE.Mesh(shapeGeometry, shapeMaterial)
scene.add(mesh)
return mesh
}
const dataToPie = (data) => {
let sum = 0
data.forEach( (datium,i) => {
const radian = datium.rate/100 * (Math.PI * 2)
createPie(sum, radian+sum, colorSet[i], radian)
sum+=radian
})
}
dataToPie(data)
// 新增環境光
const addAmbientLight = () => {
const light = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(light)
}
// 新增點光
const addPointLight = () => {
const pointLight = new THREE.PointLight(0xffffff, 0.2)
scene.add(pointLight);
pointLight.position.set(3, 3, 3)
pointLight.castShadow = true
// 新增Helper
const lightHelper = new THREE.PointLightHelper(pointLight, 20, 0xffff00)
// scene.add(lightHelper);
// 更新Helper
lightHelper.update();
}
// 新增平行光
const addDirectionalLight = () => {
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(20, 20, 20)
scene.add(directionalLight);
directionalLight.castShadow = true
const d = 10;
directionalLight.shadow.camera.left = - d;
directionalLight.shadow.camera.right = d;
directionalLight.shadow.camera.top = d;
directionalLight.shadow.camera.bottom = - d;
// 新增Helper
const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 20, 0xffff00)
// scene.add(lightHelper);
// 更新位置
directionalLight.target.position.set(0, 0, 0);
directionalLight.target.updateMatrixWorld();
// 更新Helper
lightHelper.update();
}
addAmbientLight()
addDirectionalLight()
addPointLight()
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild( renderer.domElement );
// 在camera, renderer宣告後之後加上這行
new OrbitControls(camera, renderer.domElement);
scene.background = new THREE.Color(0xffffff)
function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();
上一篇我們提到,線段本身並沒有Mesh。
我這邊再重新補充一次:我們之所以可以在畫面中看到一個物件,是因為我們在渲染一個物件時,給定three.js一個Geometry
以及一個Material
來得到一個Mesh
。那為什麼three.js可以透過這兩個東西去渲染物件?那是因為在底層的WebGL由vertexShader以及fragmentShader所組成。前者透過Geometry
抓到錨點在螢幕上的位置,後者得到前者的錨點位置再指定每一個像素的顏色。
所以說,如果沒有錨點資料能夠提供給three.js作為Geometry
,那three.js就沒辦法把錨點資料傳送給WebGL裡面的vertexShader,那麼fragmentShader也沒辦法依據錨點給定顏色。
由上面可以得知,線段終究要組成Geometry才能渲染在像素上。而要組成Geometry有很多種方式,下面是一張我整理的圖表,箭頭代表物件可以轉換的對象。
藍色表示線段可以轉成的Geometry。你可以看到主要有四個:ShapeGeometry
、ExtrudeGeometry
、BufferGeometry
、TubeGeometry
四種。
這四個Geometry
的差異如下:
ShapeGeometry
:產生一個具有面的形狀ExtrudeGeometry
:產生一個具有體積的物體BufferGeometry
:由用戶代入錨點位置而不指定任何作用。所以它有可能是三角面位置資訊,也可能是三角面Normal資訊,有可能是其他資訊。TubeGeometry
:沿著線段產生一條「水管」在上一篇,由於只需要做出平面的圓餅圖,所以我們使用ShapeGeometry
。而今要做出3D的圓餅圖,那麼是用ExtrudeGeometry
最為合適。
- const shapeGeometry = new THREE.ShapeGeometry(shape)
+ const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
+ depth: depth*2, // 隆起高度
+ steps: 1, // 在隆起的3D物件中間要切幾刀線
+ bevelEnabled: false, // 倒角(隆起向外擴展)
+ })
ExtrudeGeometry
提供很多參數,先講三個:
Depth
:隆起高度Step
:在隆起的3D物件中間要切幾刀線bevelEnabled
:在隆起的Extrude面上是否要再向外擴展可見下圖:
最後完成會長這樣:
我們的形狀長出來了,然而沒有陰影使人看不清楚圖表。
為什麼會沒有陰影?那是因為我們所選用的材質為MeshBasicMaterial
。
Material可以比擬為物件所穿著的衣服,MeshBasicMaterial
使得顏色都一致。但如果要製作一個有亮暗面的材質,使用其它種Material即可。我都使用MeshStandardMaterial
,原因是因為在3D建模軟體輸出時(例如在Maya使用Verge3D輸出GLTF
格式模型),MeshStandardMaterial
為常見的材質輸出結果。由此可以獲得GLTF
格式的最大相容性。
- const shapeMaterial = new THREE.MeshBasicMaterial({color: color})
+ const shapeMaterial = new THREE.MeshStandardMaterial({color: color})
修改後,就可以看到效果:
雖然有了立體感,但看過去不是那麼清楚。主要問題是高度不一。高高低低的,很難讓人比較各個餅之間的差異。
為了解決這個問題,我們幫餅排序即可。
// 在data進入forEach之前加上sort即可
data = data.sort((a,b) => b.rate - a.rate)
可以看到餅已經清楚很多。
接著你會發現,邊緣太銳利了,很沒有質感。
要做到最好看的特效,當然不能放著這個不管,畢竟銳利的邊緣使人感到東西廉價。
前面我們在extrude
時,有一個參數叫做bevel
,打開它即可。
const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
...
bevelEnabled: false, // 倒角(隆起向外擴展)
})
在以前學校的Maya老師稱它倒角。倒角有四個參數,下面我用圖片解釋:
bevelSize
: extrude方向如果是向上,那這參數調整左右向外擴張的程度bevelThickness
: extrude方向如果是向上,那這參數調整倒角向上增厚的程度bevelOffset
: 製作倒角之前的位移bevelSegments
: 倒角的細緻度必須注意跟Maya的不同
在Maya使用Bevel時,並沒有新增度厚度,而是直接向內切出倒角,這個概念跟three.js非常不同。如果是建模師在認識three.js的Bevel時,必須注意。
我們釐清原理之後,參數設定上更加方便。直接加上參數就能夠完成Bevel
,使得我們的模型更有高級感。
可以發覺不預期的顏色跑出來了。
這是因為Bevel互相重疊導致。
為了解決這個問題,我們只要把餅從原點向外位移即可。
要做這樣的,首先需要知道箭頭方向。
箭頭的方向,就使弧線的起始角度與終點角度的中間值。
const middleAngle = (startAngle + endAngle) / 2
取得中間的角度之後,我們透過三角函數,算出其角度所指的單位向量,並乘上倒角的大小(0.2)
由上圖可知,中間的角度可以拆成X,Y兩軸的長度,而X
數值為向量的斜邊分之對邊,Y
數值為向量的斜邊分之鄰邊。
const x = Math.cos(middleAngle)
const y = Math.sin(middleAngle)
接著只要再乘上倒角大小即可。
shapeGeometry.translate(x*0.2, y*0.2, 0)
這樣就完成了。
你看得到還是有一點瑕疵,那是因為我們向外移動的不夠多。然而一旦我們向外,圓餅圖的中心又會中空。
要解決這個問題,就是在實例化餅之前,先預留Bevel的角度。但由於篇幅關係我就暫時不討論這個問題的解決解法,我先繼續專注在餅的開發。因為我們的餅目前為止只有顏色跟比例,如果沒有加上圖例(Legend)的話,沒有人知道這些餅的意義。
如同在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」所提到的浮動文字製作方法一樣,我們需要準備字體。字體選用開源字體粉圓體。
import { FontLoader } from 'https://unpkg.com/three@latest/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'https://unpkg.com/three@latest/examples/jsm/geometries/TextGeometry.js';
const loader = new FontLoader();
loader.load( 'https://storage.googleapis.com/umas_public_assets/michaelBay/day13/jf-openhuninn-1.1_Regular_cities.json', function ( font ) {
//所有網頁邏輯
})
我們所有的邏輯都等到字體載入之後才會執行。
字體方面使用粉圓體,下載之後透過facetype.js將字體檔轉成json格式,再匯入到專案中即可。
文字Mesh如同其它種類的3D物件一樣,都需要形狀跟材質才能實例化。這邊使用TextGeometry
以由字體產生錨點,最終能夠做成形狀。
// 該函式新增文字Mesh
const addText = (text, color) => {
// 文字geometry
const textGeometry = new TextGeometry( text, {
font: font, //字體
size: 2,//大小
height: 0.01,//文字厚度
curveSegments: 2, // 文字中曲線解析度
bevelEnabled: false, // 是否用bevel
} );
const textMaterial = new THREE.MeshBasicMaterial({color: color})
const textMesh = new THREE.Mesh(textGeometry, textMaterial)
scene.add(textMesh)
return textMesh
}
// 執行函式測試一下
const text =addText('openhuninn', 0xfff000)
// 防止文字被圓餅圖擋住
text.position.z = 8
新增後可以看到文字照常呈現。
在createPie()
裡面執行addText()
,使得每個餅都可以加上文字
const createPie = (startAngle, endAngle, color, depth) => {
...
const text = addText('openhuninn', color)
...
}
雖然每個餅都有文字了,但還有兩個問題:
createPie()
外層,只要傳入內層即可。先處理文字問題。將文字傳入函式中:
// 加上參數legned
+ const createPie = (startAngle, endAngle, color, depth, legend) => {
- const createPie = (startAngle, endAngle, color, depth) => {
...
// 使用legend作為文字內容
+ const text = addText(legend, color)
- const text = addText('openhuninn', color)
...
})
// 執行函式的地方也必須把參數補齊
createPie(..., ..., ..., ..., datium.name)
接下來安排文字的位置。透過已經算好的三角函數,再乘上半徑即可。
const middleAngle = (startAngle + endAngle) / 2
const x = Math.cos(middleAngle)
const y = Math.sin(middleAngle)
// 由於圓餅圖半徑為5,所以我設比它高一點,8.5
const textDistance = 8.5
text.geometry.translate(x*textDistance,y*textDistance,0)
// 修正文字置左時的偏移
text.geometry.translate(x-([...legend].length)*0.2,y,0)
我順便修改了文字大小
const textGeometry = new TextGeometry( text, {
...
// 我修改了文字大小
- size: 2,
+ size: 0.5,
...
} );
這招我們也有在「Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字」使用過,我這邊就不詳述細節。
function animate() {
// 遞回每一個3D文字物件
texts.forEach( text => {
// 使3D文字「幾乎」看向鏡頭,同時仍被方向影響,以增加視覺豐富度
text.lookAt(...new THREE.Vector3(0,0,1).lerp(camera.position, 0.05).toArray())
})
...
}
https://codepen.io/umas-sunavan/pen/LYmmbZM
事實上,圓餅圖特效還可以加上貼圖、動畫等等先前介紹過的元素,使得畫面更加豐富。
圓餅圖可以運用的特效非常多,而且不只圓餅圖,其他種類的圖表也能加以發揮。
圓餅圖介紹到這邊,希望可以藉此使更多人熟悉線段的開發。